iOSアプリに特定バージョンを対象とした強制アップデート機能を追加する

iOSアプリに特定バージョンを対象とした強制アップデート機能を追加する

Clock Icon2024.09.13

個人で開発しているアプリで予期しない問題が発生したため、特定バージョンを対象とした強制アップデートを実施可能な仕組みを実装した。

このアプリは、ゲームのスクリーンショットを読み込み、スクリーンショットのファイル名に基づいて自動的にハッシュタグを追加し、Twitter(現X)やFacebookなどのSNSに投稿するためのものだ。ユーザーからは「ハッシュタグのカスタマイズができるようにしてほしい」という要望が多く寄せられていた。

このフィードバックに対応する前準備として、ゲーム情報とそのハッシュタグの組み合わせをアプリ内データベースで管理する機能を追加して、バージョン2.2.0としてアップデートを配信した(以降バージョンはv2.2.0と省略する)。

自動マイグレーションに失敗する問題が発覚

配信しているのはiOSアプリなので、アプリ内データベースとしてCoreDataを使っている。CoreDataの自動マイグレーションが実施され、データソースが配列から CoreData に変更になったことをユーザーに意識させないことを期待していた。

しかし、アップデート後に思わぬ問題が発覚した。特定の古いバージョンからv2.2.0にアップデートすると、データベースのマイグレーションに失敗することが判明した。結果として、データベースが空になってしまい、SNSへの投稿時にハッシュタグが付与されない問題が発生した。

この状態からユーザー自身の手によって「データベースの更新」を実行すれば、データベースに新規のデータが追加され問題は解消することがわかり、SNSでアナウンスしたが、すべてのユーザーがそれをみて対処してくれるわけではない。そもそも大半のユーザーが App Store経由でアプリをインストールしており、Twitter(現X)にアカウントがあることを知っているわけではないだろう。

特定バージョンを対象とした強制アップデートの必要性を感じる

急ぎ不具合を修正した v2.2.1をリリースして、新規に問題が発生することは抑止できたが、このアプリには特定のバージョンを対象にして強制的にアップデートさせる仕組みがない。現状取り得る選択肢としては以下の2つである。

  • v1.0.0からv2.1.0までのユーザー全員を対象にして強制アップデートをかける
  • v2.2.0のユーザーが問題に気づいて自らv2.2.1にアップデートしてくれるのを祈る

関係のない v1.0.0からv2.1.0までのユーザーに影響を与えずに、v2.2.0のユーザーのみを対象にして強制アップデートを促す仕組みが必要であると痛感した。

既存の強制アップデート

これまでもアプリには、一般的な強制アップデートの仕組みを導入していた。以下のような config.json を使って、アプリバージョンのチェックを行っていた。

{
  "url": "https://apps.apple.com/jp/app/id6504763618?mt=8",
  "version": "1.4.0"
}

この仕組みは以下の手順で動作している。

  1. アプリ起動時にサーバーからconfig.jsonをダウンロードする
  2. config.jsonに記載されているバージョンと実行中のアプリバージョンを比較する
  3. 実行中のバージョンが古ければ、強制アップデート画面に遷移してユーザーにアップデートを促す

この方法で、指定したバージョン以下のユーザーに対して強制アップデートを促すことができるため、通常の「強制アップデート」の運用では問題ない。しかし、今回のような v1.0.0〜v2.1.0 までは問題なく、v2.2.0 のみ問題がある といったような、特定のバージョンをターゲットにしてアップデートを強制したい場合には使うことができない。

特定のバージョンを対象にして強制アップデートを実行する

考えた解決策

特定のバージョンだけに強制アップデートを適用するために、いくつかの方法を検討した。

  • アプリバージョンごとに個別のconfig.jsonを用意し、それぞれのアプリで参照させる
  • config.jsonに、minimum_versionspecific_versionsというプロパティを追加し、これらを元にチェックを行う

前者の方法は、WebAPIを準備して、アプリバージョンをパラメータにすることで、各バージョンに応じた config.json を返す仕組みを作ることができる。ただし、個人開発の規模では、管理コストが大幅に増加してしまうため現実的ではない。

そこで、後者の方法を採用して config.json に特定バージョンのプロパティを追加して対応することにした。

実装

以下のような config.json を用意した。

{
  "url": "https://apps.apple.com/jp/app/id6504763618?mt=8",
  "version": "1.4.0",
  "minimum_version": "1.4.0",
  "specific_versions": ["2.2.0"]
}

この config.json では、minimum_versionで最低限アップデートが必要なバージョンを指定して、さらにspecific_versionsに特定のバージョン(今回の場合は2.2.0)を指定する。このリストに含まれているバージョンのユーザーに対して、強制的にアップデートを促すようにする。また version は、後方互換のために残している。

次にデシリアライズするモデルを用意した。

struct ConfigResponse: Sendable, Decodable {
    let url: String
    let minimumVersion: String
    let specificVersions: [String]?

    enum CodingKeys: String, CodingKey {
        case url
        case minimumVersion = "minimum_version"
        case specificVersions = "specific_versions"
    }
}

最後に強制アップデートをおこなうかどうか判定をおこなっている CheckForceUpdateUseCase の中で、以下のようにチェックする。

import Foundation

enum CheckForceUpdateUseCaseResult {
    // アップデートの必要はない
    case noUpdateNeeded

    // 強制アップデートをおこなう
    case updateRequirement(ConfigResponse)
}

/// 強制アップデートファイルの確認
class CheckForceUpdateUseCase: UseCaseProtocol {
    typealias Input = Void
    typealias Output = CheckForceUpdateUseCaseResult

    private let configUrl = URL(string: "https://example.com/ios/config.json")

    private let bundleShortVersion: String?

    init(
        bundleShortVersion: String? = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
    ) {
        self.bundleShortVersion = bundleShortVersion
    }

    func execute(_: Input) async -> Result<Output, any Error> {
        return await loadConfig()
    }

    private func loadConfig() async -> Result<Output, any Error> {
        do {
            var request = URLRequest(url: configUrl!)
            request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
            request.timeoutInterval = 10
            let (data, response) = try await URLSession.shared.data(for: request)

            guard
                let httpResponse = response as? HTTPURLResponse,
                httpResponse.statusCode == 200
            else {
                // ネットワークのアクセス経路に異常がある
                throw NSError(domain: "app.example", code: 393)
            }

            let config = try JSONDecoder().decode(ConfigResponse.self, from: data)
            return await checkVersion(config: config)
        } catch {
            return .failure(error)
        }
    }

    func checkVersion(config: ConfigResponse) async -> Result<Output, any Error> {
        guard let version = bundleShortVersion else {
            return .success(.noUpdateNeeded)
        }

        // 最低バージョンを確認
        if version.compare(config.minimumVersion, options: .numeric) == .orderedAscending {
            return .success(.updateRequirement(config))
        }

        // 強制アップデート対象のバージョンリストを確認
        if let specificVersions = config.specificVersions, specificVersions.contains(version) {
            return .success(.updateRequirement(config))
        }

        return .success(.noUpdateNeeded)
    }
}

以上で、特定のバージョンのみを対象として強制アップデートさせる仕組みが整った。

サーバーが落ちている場合やネットワークが不安定な時のケースについては特に記載していないが、リトライ処理やキャッシュした前回のconfig.jsonを利用するなどの処理については必要に応じて実装して欲しい。

まとめ

この対応によって、今後は特定のバージョンを対象にして強制アップデートを実行することができるようになった。特に、トラブルが発生したバージョンから修正済みのバージョンへアップデートが可能になったことで、問題発生時の影響を最小にし、また破壊的な変更を必要以上に恐れずに実施していけるようになった。今後もユーザーからのフィードバックを大切にし、さらに良いアプリ体験を提供していきたいと思う。

この記事をシェアする

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.